Skip to content

Conversation

@ultramiraculous
Copy link
Contributor

@ultramiraculous ultramiraculous commented Dec 9, 2025

It appears that using ResultBuilders to create a parameter-pack type via buildPartial(first:) / buildPartial(accumulated:next:), the resulting packing is not optimal.

For reasons that weren't initialy clear, each round of creating a tuple via a result builder would result in deeper and deeper tuple nesting.

Background

I've detailed my findings in #85924, but I found the solution to this via a chance change to BuilderTransform while doing other work to try to erase the @lValue-ness of parameters created in BuilderTransform::buildCall.

After making a chance change ("Why are these vars and not lets may as well change this..."), the compiler I was building suddenly worked, when all I had added otherwise was logging!

In this light, the solution to #85837 is simply this change:

lib/Sema/BuilderTransform.cpp
VarDecl *ResultBuilder::buildVar(SourceLoc loc) {
  auto &ctx = DC->getASTContext();
  // Create the implicit variable.
  Identifier name =
      ctx.getIdentifier(("$__builder" + Twine(VarCounter++)).str());
  auto var = new (ctx)
-      VarDecl(/*isStatic=*/false, VarDecl::Introducer::Var, loc, name, DC);
+      VarDecl(/*isStatic=*/false, VarDecl::Introducer::Let, loc, name, DC);
  var->setImplicit();
  return var;
}

Repro

Given this result builder:

@resultBuilder public enum TupleBuilder {
    public static func buildPartialBlock<T>(first: T) -> (T) {
        return first
    }

    public static func buildPartialBlock<each A, B>(accumulated: (repeat each A), next: B) -> (repeat each A, B) {
        return (repeat each accumulated, next)
    }
}

func builder<each A>(@TupleBuilder content: ()->(repeat each A)) -> (repeat each A) {
    return content()
}

I would expect this code (Example, Constraints):

let test = builder {
    "a"
    2
    "c"
    4
}

To be equivalent to (Example, Constraints):

let test = {
    let __builder0 = "a"
    let __builder1 = 2
    let __builder2 = "c"
    let __builder3 = TupleBuilder.buildPartialBlock(first: __builder0)
    let __builder4 = TupleBuilder.buildPartialBlock(accumulated: __builder3, next: __builder1) 
    let __builder5 = TupleBuilder.buildPartialBlock(accumulated: __builder4, next: __builder2)
    return __builder5 // (String, Int, String)
}()

But it is in fact constructed like so:

let test = {
    var __builder0 = "a"
    var __builder1 = 2
    var __builder2 = "c"
    var __builder3 = TupleBuilder.buildPartialBlock(first: __builder0)
    var __builder4 = TupleBuilder.buildPartialBlock(accumulated: __builder3, next: __builder1) 
    var __builder5 = TupleBuilder.buildPartialBlock(accumulated: __builder4, next: __builder2)
    return __builder5 // ((String, Int), String)
}()

Issue

I've documented this behavior in #85924 , but when parameter-packed generics are passed a reference to a var value, the

If you continue you end up with tuples packed like:

(((String, Int), String), Int)
((((String, Int), String), Int) String)
(((((String, Int), String), Int), String), Int)
/* and so on */

Change

The change is pretty straight forward, just make all the VarDecl's into lets:

lib/Sema/BuilderTransform.cpp
VarDecl *ResultBuilder::buildVar(SourceLoc loc) {
  auto &ctx = DC->getASTContext();
  // Create the implicit variable.
  Identifier name =
      ctx.getIdentifier(("$__builder" + Twine(VarCounter++)).str());
  auto var = new (ctx)
-      VarDecl(/*isStatic=*/false, VarDecl::Introducer::Var, loc, name, DC);
+      VarDecl(/*isStatic=*/false, VarDecl::Introducer::Let, loc, name, DC);
  var->setImplicit();
  return var;
}

Concerns

This is the way I'd expect this to act, but I'm not sure if everyone agrees or if there's not existing code that might be dependent on this behavior.

I've documented my thoughts in #85837 and in the thread in the Swift forums.

Impact

This changes all result builders to have different semantics internally and in at least this case, the type returned from a builder could be substantively different.

This would allow for maximally-flattened tuples without workarounds, but it might break any clients explicitly depending on this nested-packing behavior.

Altertative/Enhanced Options

Multiple constraints:

Is it possible to have have both options as constraints? As in generate one constraint with let which takes prescidence, but if specifically requested, fall back to the var equivalent?

I'm not sure what the constraint solver overhead for this maneuver is.

Fix #85924 (Parameter packed functions pack less efficiently if the input is a var):

Solving what seems like the root issue would potentially obviate the need for this particular fix. The scope/impact of the change would be greatly increased, though. It seems like it carries considerably more risk to the system.

Apply this change and fix #85924 :

Unless I'm missing something (do...result builders support inouts?), it seems sub-optimal, or at least unecessary, to have every value generated by a builder be a var and never mutated.

If there's a larger fix for #85924 that also fixes this issue, it might still be worth taking this change.

Misc

My first interaction with parameter packs was also me finding this bug, so I'm not entirely sure how they're supposed to work, packing wise (are less optimal packings still valid)?

Mainly (String, Int, String) seems like it should be the right "choice", but I'm unclear on if ((String, Int), String) is also a vailid option.

If the latter is a valid option there may be additional bugs (see #85924). There also may be existing code that depends on the ((String, Int), String)-style choice.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parameter packed functions pack less efficiently if the input is a var

1 participant